iT邦幫忙

2022 iThome 鐵人賽

DAY 24
0
Modern Web

開始搞懂React生態系系列 第 24

Day 24 Redux 非同步 Action 解決方案 - redux-observable

  • 分享至 

  • xImage
  •  

RxJS

在介紹 redux-observable 之前,先稍微理解一下 RxJS 的基本概念,RxJS 可以幫助我們解決很多非同步事件的疑難雜症,它提供了一套完整的非同步解決方案。

Web 存取各種資源大多是非同步(Async)的,隨著網頁需求的複雜化,我們所寫的 JavaScript 就有各種針對非同步行為的寫法,如下圖所示。

使用 Callback、Generator 或是 Promise 甚至是新的語法糖 async/await,但隨著應用需求愈來愈複雜,撰寫非同步的程式碼仍然非常困難。

簡單舉例說明 RxJS 簡化非同步

假如想監聽點擊事件(click event),但點擊一次之後不再監聽。

一般的寫法如下

const handler = (e) => {
  console.log(e);
  document.body.removeEventListener('click', handler); 
  //  結束監聽
}

// 註冊監聽
document.body.addEventListener('click', handler);

改寫用 RxJS

Rx.Observable
  .fromEvent(document.body, 'click') // 註冊監聽
  .take(1) // 只取一次
  .subscribe(console.log);

透過 RxJS 的 API 來做資料操作,可以發現程式碼比較清楚易懂,每個步驟在做什麼事情。RxJS 是一套藉由 Observable sequences 來組合非同步行為和事件基礎程序的 Library!(你可以想像它是用來處理非同步行為的 Lodash)

由於 RxJS 可通用於所有非同止的操作,所以當專案的非同步操作是複雜精密的,就可以考慮引入 RxJS 來解決問題。

如果想深入理解 RxJS 的話,就不要錯過這個鐵人賽冠軍系列文章 - 30 天精通 RxJS

redux-observable

先假定大家都有 RxJS 的基本觀念,那就來看看如何應用 redux-observable 解決 Redux 非同步的問題。

前情提要

回顧一下 Redux Middleware 所扮演的角色,前面的文章有介紹 React Middleware 的 基本運作原理。

epics

epics 是 redux-observable 核心概念

  • epics 是一個「 actions in, actions out 」的函數
function (
    action$: Observable<Action>, 
    state$: StateObservable<State>
): Observable<Action>;
  • 在 dispatch action 後中間經過 epics 處理,再發送 action 回到reducer (從這個特性來看,是不是就是redux middleware的概念呢!)

安裝 RxJS 及 redux-observable

React Redux 使用 redux-observable 來實現 RxJS

npm install rxjs –save
npm install redux-observable –save

使用 redux-observable

這邊我們沿用前面的範例,把 setFilterAsync 改寫成 使用 redux-observable 的寫法。

調整 Action Types 及 Action Creators

// 新增一個 ActionTypes
export const SET_FILTER_DELAY = "SET_FILTER_DELAY";
// store/actions/index.js

import { SET_FILTER, SET_FILTER_DELAY, ...} from "./actionTypes";

export const setFilter = (filter) => {
  return {
    type: SET_FILTER,
    filter
  };
};

// 新增一個 action creator 讓它可以指定 delay 多久
export const setFilterDelay = (filter, delay) => {
  return {
    type: SET_FILTER_DELAY,
    filter,
    delay
  };
};

撰寫 epics 函數

  • 通常會先使用 filter rxjs operator 來指定要對應的 action 資料流
  • redux-observable 增加了 ofType,讓你可以應用在多個 action 資料流
action$.pipe(ofType(FIRST, SECOND, THIRD)) // FIRST or SECOND or THIRD
// store/epics/index.js

import { SET_FILTER_DELAY } from "../actions/actionTypes";
import { setFilter } from "../actions";

import { ofType } from "redux-observable";
import { interval } from "rxjs";
import { map, delayWhen } from "rxjs/operators";

export const setFilterDelayEpic = (action$) =>
  action$.pipe(
    // filter(({ type }) => type === SET_FILTER_DELAY),
    ofType(SET_FILTER_DELAY),
    // 使用 delayWhen 搭配 interval 做參數自訂的 delay
    delayWhen(({ delay }) => interval(delay)),
    // delay 後才執行 setFilter(filter)
    map(({ filter }) => setFilter(filter))
  );

把 epics 函數 setup 到 store

import { createStore, applyMiddleware } from "redux";
import reducers from "./reducers";

import { combineEpics, createEpicMiddleware } from "redux-observable";
import { setFilterDelayEpic } from "./epics";

export default function configureStore() {
  // createEpicMiddlewarec會將epic函數轉為redux中間件
  const epicMiddleware = createEpicMiddleware();
  const enhancers = applyMiddleware(epicMiddleware);
  const store = createStore(reducers, enhancers);

  // combineEpics會將參數中的epic函數合併在一起
  const epics = combineEpics(setFilterDelayEpic);
  // 這段要放在 createStore() 後執行
  epicMiddleware.run(epics);

  return store;
}

在元件中使用

  • 執行 configureStore() 取得 store instance
// src/index.js

import configureStore from "./store";
...
const store = configureStore();

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);
  • 在 Footer 元件 dispatch setFilterDelay
import { setFilterDelay } from "../store/actions";
...
dispatch(setFilterDelay(filterTitle, 2000))}

這時候切換 Filter 會發現資料會延遲我們指定的秒數才做出變化。

完整程式碼操作:https://codesandbox.io/s/react-todomvc-redux-observable-delay-4n6y1j

使用 redux-observable 串接 API

一樣沿用前面的範例,把 fetchTodosAsync 改寫成 使用 redux-observable 的寫法。

調整 Action Types、Action Creators 及 Reducer

export const SET_FILTER_DELAY = "SET_FILTER_DELAY";
// 新增 ActionTypes 如下
export const FETCH_TODOS = "FETCH_TODOS";
export const FETCH_TODOS_SUCCESS = 'FETCH_TODOS_SUCCESS';
// store/actions/index.js

import {
  ...,
  SET_FILTER,
  SET_FILTER_DELAY,
  FETCH_TODOS,
  FETCH_TODOS_SUCCESS
} from "./actionTypes";

export const fetchTodos = (data) => {
  return {
    type: FETCH_TODOS,
    data
  };
};

// 新增一個 action creator 讓 fetch api 成功後可以對應執行
export const fetchTodosSuccess = (data) => {
  return {
    type: FETCH_TODOS_SUCCESS,
    data
  };
};
  • 在 Reducer Fuction 加上 FETCH_TODOS_SUCCESS 的對應
// store/reducers/todosReducer.js

import {FETCH_TODOS_SUCCESS, ...} from "./actionTypes";

switch (action.type) {
  ...
  // reducer 裡不要有 FETCH_TODOS 的對應,而是 FETCH_TODOS_SUCCESS
  case FETCH_TODOS_SUCCESS:
    const newTodos = action.data.map(
      ({ id, title, completed }) => {
      return {
        id,
        text: title,
        completed
      };
    });
    return [...newTodos];
  ...
}

撰寫 epics 函數

import { ofType } from "redux-observable";
import { ajax } from "rxjs/ajax";
import { map, delayWhen, mergeMap } from "rxjs/operators";

const url = "https://jsonplaceholder.typicode.com/users/1/todos";

export const fetchTodosEpic = (action$) =>
  action$.pipe(
    // filter(({ type }) => type === FETCH_TODOS),
    ofType(FETCH_TODOS),
    mergeMap((action) =>
      ajax.getJSON(url).pipe(map((response) => fetchTodosSuccess(response)))
    )
  );

把 epics 函數 setup 到 store

import { setFilterDelayEpic, fetchTodosEpic } from "./epics";
...
  // combineEpics會將參數中的epic函數合併在一起
  const epics = combineEpics(setFilterDelayEpic, fetchTodosEpic);
  // 這段要放在 createStore() 後執行
  epicMiddleware.run(epics);

在元件中使用

  • 在 Footer 元件 dispatch fetchTodos
import { setFilterDelay, fetchTodos } from "../store/actions";
...
dispatch(fetchTodos());

完整程式碼

完整程式碼操作:https://codesandbox.io/s/react-todomvc-redux-observable-api-pd2u6g

使用 redux-observable 取消非同步操作

參考 官網說明,可以再進一步修改之前串接 API 的範例,追加一個 fetchTodosCancel 的功能。

前置作業

  • 先追加一個有增加 Loading 功能的 Sandbox 範本

這邊有稍微調整 todos 狀態的結構如下,所以相對應的 reducer 及 元件取得 state 的方式稍微不同。

const initialState = {
  data: [],
  isLoading: false,
  error: false
};

這裡不多贅述,大家可以直接去看 調整後的結果

調整 Action Types、Action Creators 及 Reducer

  • 調整 Action Types、Action Creators
export const SET_FILTER_DELAY = "SET_FILTER_DELAY";
export const FETCH_TODOS = "FETCH_TODOS";
export const FETCH_TODOS_SUCCESS = "FETCH_TODOS_SUCCESS";
// 新增 ActionTypes 如下
export const FETCH_TODOS_CANCEL = "FETCH_TODOS_CANCEL";
// store/actions/index.js

import {
  ...,
  SET_FILTER,
  SET_FILTER_DELAY,
  FETCH_TODOS,
  FETCH_TODOS_SUCCESS,
  FETCH_TODOS_CANCEL
} from "./actionTypes";

...

export const fetchTodosCancel = () => {
  return {
    type: FETCH_TODOS_CANCEL
  };
};
  • 在 Reducer Fuction 加上 FETCH_TODOS_CANCEL 的對應
// store/reducers/todosReducer.js

import {FETCH_TODOS_SUCCESS, ...} from "./actionTypes";

switch (action.type) {
  // 這裡要加 FETCH_TODOS 用來做 isLoading 狀態的控制
  case FETCH_TODOS:
    return Object.assign({}, state, {
      data: [...state.data],
      isLoading: true,
      error: null
    });
  case FETCH_TODOS_SUCCESS:
    const newData = action.data.map(({ id, title, completed }) => {
      return {
        id,
        text: title,
        completed
      };
    });
    return Object.assign({}, state, {
      data: [...newData],
      isLoading: false,
      error: null
    });
  // 加上 FETCH_TODOS_CANCEL
  case FETCH_TODOS_CANCEL:
    return Object.assign({}, state, {
      data: [],
      isLoading: false,
      error: null
    });
}

調整 epics 函數

import { ofType } from "redux-observable";
import { ajax } from "rxjs/ajax";
// 增加一個 takeUntil 的 operator
import { ..., takeUntil } from "rxjs/operators";


export const fetchTodosEpic = (action$, { dispatch }) =>
  action$.pipe(
    // filter(({ type }) => type === FETCH_TODOS),
    ofType(FETCH_TODOS),
    mergeMap(() =>
      ajax.getJSON(url).pipe(
        map((response) => {
          return fetchTodosSuccess(response);
        }),
        // 如果有執行 FETCH_TODOS_CANCEL 就取消 Fetch
        takeUntil(action$.pipe(ofType(FETCH_TODOS_CANCEL)))
      )
    )
  );

在元件中使用

  • 在 Footer 元件加上 Load Cancel,按下後會發 dispatch(fetchTodosCancel())
import { setFilterDelay, fetchTodos } from "../store/actions";
...
  <span
    style={{ zIndex: 10 }}
    onClick={() => {
      dispatch(fetchTodosCancel());
    }}
  >
    Load Cancel
  </span>

完整程式碼

完整程式碼操作:https://codesandbox.io/s/react-todomvc-redux-observable-api-cancellation-loading-d9i9nm

Next

在前面幾篇介紹 Redux 的文章,可以發現建置 Redux 不是一件容易的工作,所以 Redux 官方後來提供了一個工具包 - Redux Toolkit,它是一個可以幫助你更有效率撰寫 Redux 的一個 library,它提供了一些 API 讓你更方便的建立 Store、Actions 和 Reducers。

Reference

https://redux-observable.js.org/

https://ithelp.ithome.com.tw/articles/10230156

https://medium.com/mizyind-singularity/redux-%E7%95%B0%E6%AD%A5-action-%E8%A7%A3%E6%B1%BA%E4%BA%8C%E5%BC%8F-observable-%E6%B3%95-989a9ff174cf

https://www.facebook.com/photo?fbid=5130624853619445&set=gm.3014867732115273

https://www.slideshare.net/newstory0113/why-reduxobservable

https://pjchender.dev/webdev/note-without-redux/

https://dev.to/andrejnaumovski/async-actions-in-redux-with-rxjs-and-redux-observable-efg


上一篇
Day 23 Redux 非同步 Action 解決方案 - redux-thunk
下一篇
Day 25 更有效率撰寫 Redux - Redux Toolkit
系列文
開始搞懂React生態系30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言